react hook实践
又到了跟着文档码字学习的阶段,hook从提案到现在已经很久了。在这之前但是还没有真正地去了解这个 react 新玩具。跟随文档学习,并尝试重构一些项目
简介
官方自带的视频已经很好地介绍了 hook
概览
Hook 是 React.16.8 新增特征,可以让你在不编写 class的情况下使用 state 以及其他 React 特性
State Hook
简单的示例:
1 | import React,{useState} from 'react' |
通过在函数组件里调用它来给组件添加一些内部的 state,React 会在渲染的时候保留这个 state。useState会返回一堆值:当前值和一个让你更新它的函数,可以在时间处理函数中或者其他一些地方调用这个函数。类似 class组件的 this.setState,但是它不会把新的 state和旧的 state进行合并。
useState 唯一的参数就是初始的state。上面的例子中,计数器从零开始的,所有初始 state就是0。注意不同于 this.state,这里的 state不一定要是一个对象。
声明多个变量
在一个组件中声明多个变量
1 | function ExampleWithManyStates(){ |
Hook?
Hook 是一些可以让你在函数组件里 钩入 React state以及生命周期函数等特性的函数。Hook 不能在 class 组件中使用。使得不用 class 也可以使用 React.
Effect Hook
React 组件中数据获取、订阅或者手动修改 DOM,都统称为副作用,或者称为作用
useEffect就是 Effect Hook,给函数组件增加了操作副作用的能力,跟 class 组件的 componentDid、componentDidUpdate、componentWillUnmount具有相同的用途,只不过被合并成了一个AOU
例子,在 React 更新 DOM 后设置一个页面的标题
1 | import React, { useState, useEffect } from 'react' |
当调用 useEffect,就是在告诉 React 在完成对 DOM 的更改后运行副作用函数,由于副作用函数是在组件内声明的,所有可以访问到组件的 props或者 state。默认情况下,React 会在每次渲染后调用副作用函数——包括第一次渲染的时候
副作用函数还可以通过返回一个函数来指定清除副作用。例如,在下面的组件中使用副作用函数来订阅好友的在线状态,并通过取消订阅来进行清除操作:
1 | import React,{useState,useEffect} from 'react' |
上面的实例中,React会在组件销毁或者后续渲染时重新执行副作用函数,取消对 ChatAPI的订阅。
跟 useState一样,可以在组件中多次使用 useEffect
1 | function FriendStatusWithCounter(props){ |
通过使用 Hook,可以把组件内相关的副作用组织在一起(例如创建订阅以及及时取消),而不要把它们拆分到不同的生命周期函数
Hook使用规则
Hook 就是 javascript 函数,但是使用它们会有两个额外的规则:
只能在函数最外层调用Hook。不要在循环、条件判断或者子函数中调用
只能在React 的函数组件中调用Hook。
自定义 Hook
在之前,组件之间复用一些状态逻辑,有两种主流方案:高阶组件、render props。自定义 Hook可以在不增加组件的情况下达到相同的目的
FriendStatus组件,通过调用 useState和 useEffect的 Hook 来订阅一个好友的在线状态,假设我们想在另一个组件里复用这个订阅逻辑
首先,把逻辑提取到一个叫做 useFriendStatus的自定义 Hook里:
1 | import React,{useState,useEffect} from 'react' |
它将 friendID作为参数,并返回该好友是否在线,我们可以在两个组件中用到它:
1 | function FriendStatus(props){ |
两个组件中的 state 是完全独立的。Hook 是一种复用状态逻辑的方式,不复用 state 本身,事实上,Hook 每次调用都有一个完全独立的 state,因此可以在单个组件中多次调用同一个自定义 Hook
其他Hook
还有一些使用频率较低的但很有用的 Hook,比如使用 useContext可以不使用组件嵌套订阅 React 的 Context
1 | function Example(){ |
使用 State Hook
1 | // class 示例 |
Hook和函数组件
1 | const Example = (props)=>{ |
Hook 在 class 内部是不起作用的,可以使用来替代 class
使用 Effect Hook
Effect Hook可以让你在函数组件中执行副作用操作
1 | import React,{useState,useEffect} from 'react' |
数据获取,设置订阅以及手动更改 React 组件中的 DOM 都属于副作用。在 React 组件中有两种常见副作用操作:需要清除的和不需要清除的。
无需清除的 effect
有时候,我们只想在 React 更新DOM 之后运行一些额外的代码,比如网络请求、手动变更 DOM,记录日志,这些都是常见的无需清除的操作。因为我们在执行完这些操作之后,就可以忽略他们了。
使用class
在 React 的 class 组件中,render函数是不应该有任何副作用的,一般来说,在这里执行操作太早了,我们都希望在 React 更新 DOM 之后才执行我们的操作
这也是为什么把副作用放在 componentDidMount和componentDidUpdate函数中。
下面的示例,React 计数器的 class 组件,在 React 对 DOM 进行操作后,立即更新了 document 的 title 属性
1 | class Example extends React.Component{ |
使用hook
1 | import React,{useState,useEffect} from 'react' |
与 componentDidMount 和 componentDidUpdate不同,使用 useEffect 调度的 effect 不会阻塞浏览器更新屏幕,让应用看起来响应很快。大多数情况下,effect不需要同步地执行,在个别情况下(例如测试布局),有单独的 useLayoutEffect Hook 使用
需要清除的 effect
订阅外部数据源等一些副作用是需要清除的,可以防止内存泄露。
使用class
通常会在 componentDidMount中设置订阅,在 componentWillMount中清除它。假设我们有一个 ChatApI模块,运行我们订阅好友的在线状态
1 | class FriendStatus extends React.Component{ |
使用hook
useEffect 设计在同一个地方执行添加和删除订阅,effect返回一个函数,React就会在指定清除的时候调用它
1 | import React,{useState,useEffect} from 'react' |
使用多个Effect 实现关注点分离
使用hook其中一个目的就是要解决class 中声明周期函数经常包含不相关的逻辑,但又把相关逻辑分离到了几个不同方法中的问题。上面代码是示例中计数器和好友状态指示器逻辑组合在一起的组件:
1 | class FriendStatusWithCounter extends React.Component{ |
而使用 hook,跟使用多个 state 的 hook一样,可以使用多个 effect将不相关逻辑分离到不同的 effect中
1 | function FriendStatusWithCounter(props){ |
为什么每次更新的时候都要运行effect
为什么 effect 在每次重渲染都会执行,而不是在卸载组件的时候执行一次。
上述用于显示好友是否在线的 FriendStatus 组件,从 class 中 props 读取 friend.id,然后在组件挂载后订阅好友状态,并在卸载组件的时候取消订阅
1 | componentDidMount(){ |
Hook版本
1 | function FriendStatus(props){ |
不需要特定的代码来处理更新逻辑,useEffect默认就会处理。它会在调用一个新的 effect 之前对前一个 effect 进行清理。下面按时间列出一个可能会产生的订阅和取消订阅调用序列
1 | // Mount with {friend:{id:100}} props |
默认行为保证了一致性,避免了在 class 组件因为没有处理更新逻辑而导致常见 bug
通过跳过 effect 进行性能优化
在某些情况下,每次渲染后都会执行清理或者执行 effect 可能会导致性能问题。在 class 组件中,可以通过 componentDidUpate 中添加 prevProps 或者 prevState 的比较逻辑解决:
1 | componentDidUpdate(prevProps,prevState){ |
这是很常见的需求,被内置到了 useEffect 的 hook api中,如果某些特定值在两次重渲染中没有发生变化,可以通过 React 跳过对 effect 的调用,只要传递数组作为 useEffect 的第二个参数即可:
1 | useEffect(()=>{ |
注意:
如果要使用这种优化方法,确保数组中包含了所有外部作用域中会随着时间变化并且在 effect 中使用的变量,否则代码会引用到先前渲染中的旧变量。
如果想只执行一次 effect 仅在组件挂载或者卸载时执行,可以传递一个空数组([]),作为第二个参数,告诉 React 的 effect 不依赖于props 或者 state 中的任何值,所以它永远都不需要被重复执行,这不属于特殊情况,依然遵循数组的工作方式。
如果传入了一个空数组,effect 内部的 props 和 state 就会一直拥有其初始值。尽管传入的空数组作为第二个参数更加接近熟悉的 componentDidMount 和 componentWillUnMount 思维方式。React 会等待浏览器完成画面渲染后才会延迟调用 useEffect,因此会使得额外操作很方便。
启用 eslint-plugin-react-hooks 中的 exhaustive-deps 规则。此规则会在添加错误依赖时发出警告并给出修复建议。
Hook 规则
Hook 本质就是 JS 函数,使用它需要遵循两条规则。 linter 插件来强制执行这些规则:
只在顶层使用 Hook
不要在循环,条件或者嵌套函数中调用 Hook,确保总是在 React 函数的最顶层调用它们。遵循这条规则,就能确保 hook 在每一次渲染中都按照同样的顺序被执行.
只在 React 函数中使用 Hook
不要在普通的 JS 函数中调用 Hook,可以在React 函数组件中调用 Hook,在自定义 Hook 中调用其他 Hook
遵循以上规则,确保组件的状态逻辑在代码中清晰可见
ESLINT 插件
eslint-plugin-react-hooks 的 ESLint 插件来强制执行这两条规则。如果你想尝试一下,可以将此插件添加到你的项目中:
1 | npm install eslint-plugins-react-hooks --save-dev |
1 | // ESLint 的配置 |
说明
单个组件中使用多个 State Hook或者 Effect Hook
1 | function Form(){ |
React 怎么知道哪个state 对应哪个 useState,答案是React靠Hook调用的顺序。
1 | // 首次渲染 |
只要 Hook 的调用顺序在多次渲染中保持一致,React 就能正确将内部 state 和对应的 hook 进行关联。
1 | // 倘若将一个 hook 调用放入到一个条件语句中会发生什么 |
React 不知道第二个useState 的 Hook 应该返回什么,React 以为在该组件中第二个 Hook 的调用像上次渲染一样。对应的是 persistForm的 effect,但并非如此。从这里开始,后面的 Hook调用都被提前执行,导致了bug的产生。
这就是为什么 Hook 需要在我们组件的最顶层调用,如果要有条件地执行一个 effect,可以将判断放在 Hook 的内部
1 | useEffect(function persistForm(){ |
自定义 Hook
通过自定义 Hook,可以将组件逻辑提取到可重用的函数中
1 | import React,{useState,useEffect} from 'react' |
假设聊天应用中有一个联系人列表,当用户在线时需要把名字设置为 绿色,我们可以把上面类似的逻辑复制并粘贴到 FriendListItem 组件中,但这并不是理想的解决方案
1 | import React,{useState,useEffect} from 'react' |
我们希望在FriendStatus以及FriendListItem 之间共享逻辑。
提取自定义 Hook
当我们想在两个函数之间共享逻辑时,会把它提取到第三个函数中,而组件和Hook都是函数,所以也使用这种方式
自定义Hook 是一个函数,名称以 use 开头,函数内部可以调用其他 hook,下面 useFriendStatus 就是定义的 Hook
1 | import React,{useState,useEffect} from 'react' |
此处 useFriendStatus 的 Hook 目的是订阅某个好友的在线状态。这就是我们需要将 friendID 作为参数,并返回这位好友的在线状态的原因。
使用自定义 Hook
1 | function FriendStatus(props){ |
代码等价于原来的示例代码 ,自定义 Hook 是一种自然遵循 Hook 设计的约定,而并不是 React 的特性。
自定义 Hook 必须以 “use” 开头
在两个组件中使用相同的 Hook 不会共享 state
自定义 Hook 每次调用 都会获取独立的 state
多个 Hook之间传递信息
由于 Hook 本身就是函数,因此我们可以在它们之间传递信息。
将使用聊天程序中的另一个组件来说明这一点,这是一个聊天消息接收者的选择器。会显示当前的好友是否在线
1 | const friendList = [ |
将当前选择的好友ID保存在 recepientID 状态变量中,并在用户从 Select 中选择其他好友时更新这个state
由于 useState 提供了 recipientID 状态变量的最新值,我们可以将它作为参数传递给自定义的 useFriendStatus Hook
1 | const [recipientID,setRecipientID] = useState(1) |
当我们选择不同的好友并更新 recipientID 状态变量时,useFriendStatusHook 将会取消订阅之前选中的好友,并订阅新选中的好友状态
Hook API索引
基本Hook
useState
1 | const [state,setState] = useState(initialState) |
返回一个state,以及更新 state 的函数。在初始渲染期间,返回的状态(state)与传入的第一个参数(initialState)值相同。
setState函数用于更新state,它接收一个新的state值并将组件的一次重新渲染加入队列
1 | setState(newState); |
后续渲染中,useState 返回的第一个值始终是更新后最新的 state。React会确保 setState 函数的标识是稳定的,并且不会在组件重新渲染时发生变化,这就是为什么可以安全地从 useEffect 或 useCallback 的依赖列表中省略 setState
函数式更新
如果新的state需要通过使用先前的 state计算得出,那么可以将函数传递给 setState,该函数将接收先前的 state,并返回一个更新后的值,下面的例子展示了 setState 的两种用法:
1 | function Counter({initialCount}){ |
+和-采用函数式形式,因为被更新的的 state 需要基于之前的 state,但是重置按钮则采用普通形式,因为它总是把 count 设置回初始值
与 class 组件的 setState 方法不一致,useState 不会自动合并更新对象,使用函数式的 setState 结合展开运算符达到合并更新对象的效果
1 | setState(prevState=>{ |
惰性初始 state
initialState 参数只会在组件的初始渲染中起作用,后续渲染时会被忽略。如果初始 state 需要通过复杂计算获得,则可以传入一个函数,在函数计算并返回初始 state,次函数只在初始渲染被调用
1 | const [state,setState] = useState(()=>{ |
跳过state 更新
调用 State Hook 的更新函数并传入当前的 state ,React 将跳过子组件的渲染以及 effect 的执行,React 使用Object.is 比较算法来比较state
需要注意的是,React可能仍需要在跳过渲染前渲染该组件,不过由于React不会对组件数的深层节点进行不必要的渲染,所以不用担心。如果在渲染期间执行了高开销的计算,则可以使用 useMemo 优化。
useEffect
1 | useEffect(didUpdate) |
该hook 接收一个包含命令式、并且可能有副作用代码的函数。
在函数组件主体内改变 DOM,添加订阅、设置定时器、记录日志以及执行其他包含副作用的操作都是不被允许的,因为这可能会产生莫名其妙的 bug 并破坏 UI 的一致性
使用 useEffect完成副作用操作,赋值给 useEffect的函数会在组件渲染到屏幕之后执行。默认情况下,effect 将在每轮渲染结束后执行,可以渲染让它在只有某些值的时候才执行。
清除effect
通常,组件卸载时需要清除 effect 创建的诸如订阅或者定时器ID 等资源,要实现这一点,需要返回一个清除函数。
1 | useEffect(()=>{ |
为了防止内存泄露,清除函数会在组件卸载前执行,另外,如果组件多次渲染,则在执行下一个 effect 之前,上一个 effect 就已经被清除。上面的例子意味着组件的每一次更新都会创建新的订阅,若想避免每次更新都触发 effect 的。
effect的执行时机
与 componentDidMount、componentDidUpdate 不同的是,在浏览器完成布局与绘制之后,传给 useEffect 的函数会被延迟调用。这使得它适用于很多常见的副作用场景,如设置订阅和事件处理等情况,因为不应该在函数中执行阻塞浏览器更新屏幕的操作。
然后不是所有的 effect 都可以被延迟执行的,例如在浏览器执行 下一次绘制前,用户可见的 DOM变更就必须同步执行,这样永不才不会感觉到视觉上的不一致。React 提供了 useLayoutEffect Hook 来处理这类 effect,和 useEffect 结构相同,但是调用时机不同。
虽然 useEffect 会在浏览器绘制后延迟执行,但会保证在任何渲染前执行,React 将在组件更新前刷新上一轮的渲染的 effect
effect的条件执行
默认情况下,effect 会在每轮组件渲染完成后执行,一旦 effect 的依赖发生变化,它就会被重新创建。但我们不需要再每次更新时都创建新的订阅,而仅在 props 改变的时候重新创建,可以给 useEffect 传递第二个参数,它是 effect 依赖的值数组
1 | useEffect(()=>{ |
useContext
1 | const value = useContext(myContext); |
接收一个 context 对象(React.createContext的返回值)并返回该 context 的当前值。当前 context 值由上层组件中距离当前组件最新的 <MyContext.Provider>的 value prop 决定。
当上层最近的 <MyContext.Provider>更新时,该Hook 会触发重新渲染,并使用最新传递给 MyContext provider 的 context value 值
别忘记 useContext 的参数必须是 context 对象本身:
- 正确:
useContext(MyContext) - 错误:
useContext(MyContext.Consumer) - 错误:
useContext(MyContext.Provider)
调用了 useContext的组件总会在 context 值变化时重新渲染,如果重新渲染组件开销比较大的话,可以通过 memoization 优化
useContext(MyContext)只是能够读取 context 的值以及订阅 context 的变化,仍然需要在上层组件树中使用 <MyContext.Provider>来为下层组件提供 context
额外的 Hook
useReducer
1 | const [state,dispatch] = useReducer(reducer,initialArg,init) |
它接受一个 (state,action)=> newState的 reducer,并返回一个当前的 state以及与其配套的 dispatch方法。
在某些场景下,useReducer 比 useState更适用。例如 state 逻辑复杂且包含多个子值,或者下一个 state 依赖之前的 state 等。并且,使用 useReducer 还能给那些会触发深更新的组件做性能优化,因为可以向子组件传递 dispatch 而不是回调函数。
reducer 重写 useState 的计数器
1 | const initialState = {count:0} |
指定初始state
有两种初始化 useReducer state 的方式,可以根据使用场景选择其中的一种。将初始 state 作为第二个参数传入 useReducer 是最简单的方法:
1 | const [state,dispatch] = useReducer( |
惰性初始化
选择惰性地创建初始化 state,为此需要将 init 函数作为 useReducer 的第三个参数传入,这样初始 state 将被设置为 init(initialArg)
这样做,可以将用于计算 state 的逻辑提取到 reducer 外部,这也为将来对重置 state 的 action 做处理提供了便利:
1 | function init(initialCount){ |
跳过 dispatch
如果 Reducer Hook 的返回值与当前 state 相同,React 将跳过子组件的渲染以及副作用的执行。需要注意的是,React 可能仍需要跳过渲染前再次渲染该组件。不过由于React 不会对组件树的深层节点进行不必要的渲染,所以不用担心。如果在渲染期间执行了高开销的计算,则可以使用 useMemo来进行优化
useCallback
1 | const memoziedCallback = useCallBack( |
返回一个 memoized 回调函数
把内联回调函数以及依赖数组作为参数传入 useCallback ,它将返回该回调函数的 memoized 版本,该回调函数仅在某个依赖项改变时才会跟新。当你把回调函数给经过优化的并使用引用相等性去避免非必要渲染(例如 shouldComponentUpdate)的子组件,它将非常有用
useCallback(fn,deps)相当于 useMemo(()=>fn,deps)
useMemo
1 | const memoizedValue = useMemo(()=>computeExpensiveValue(a,b),[a,b]) |
返回一个 memoized值。
把创建函数和依赖项作为参数传入 useMemo,它仅会在某个依赖项改变时才重新计算 memoized值,这种优化有助于避免在每次渲染时都进行高开销计算。
传入 useMemo 的函数会在渲染期间执行,不要在这个函数内部执行于渲染无关的操作,诸如副作用这类的操作属于 useEffect 的适用范畴,而不是 useMemo
如果没有提供依赖项数组,useMemo 在每次渲染时都会计算新的值
useRef
1 | const refContainer = useRef(initialValue) |
useRef 返回一个可变的 ref 对象,其 .current属性被初始化为传入的参数 initialValue 。返回的 ref 对象在组件的整个生命周期内保持不变。
命令式访问子组件:
1 | function TextInputWithFocusButton(){ |
useImperativeHandle
1 | useImperativeHandle(ref,createHandle,[deps]) |
useImperativeHandle可以让你在使用 ref时自定义暴露给父组件的实例值。useImperativeHandle应当与 forwardRef一起使用:
1 | function FancyInput(props,ref){ |
useLayoutEffect
其函数签名与 useEffect 相同,但它会在所有的DOM 变更之后同步调用 effect。可以使用它来读取 DOM 布局并同步触发重渲染。在浏览器执行绘制之前,useLayoutEffect 内部的更新计划会被同步刷新。尽可能使用 标准的 useEffect 以避免阻塞视觉更新
注意:
useLayoutEffect 与 componentDidMount、componentDIdUpdate的调用阶段是一样的。
如果使用服务端渲染,无论是 useLayoutEffect 或者 useEffect 都无法在 JS 代码加载完成之前执行。这就是为什么在服务端渲染组件中引入 useLayoutEffect 代码会触发React 警告。解决这个问题,需要将代码逻辑移到 useEffect 中(如果首次渲染不需要这段逻辑的情况下),或是将该组件延迟到客户端渲染完成后再显示(直到 useLayoutEffect执行之前HTML 都显示错误的情况下)
若要从服务端渲染的 HTML 中排除依赖布局 effect 的组件,可以通过使用 showChild && <Child>进行条件渲染,并使用 useEffect(()=>{setShowChild(true)},[])延迟展示组件。这样在客户单渲染完成之前,UI就不会像之前那样显示错乱了。
useDebugValue
1 | useDebugValue(value) |
useDebugValue可用于 React 开发工具中显示自定义 hook 标签
1 | function useFriendStatus(friendID){ |
延迟格式化 debug值
某些情况下,格式化值的显示可能是一项开销很大的操作,除非检查 Hook,否则没有必要这么做。因此,useDebugValue 接受一个格式化函数作为可选的第二个参数。该函数只有在 hook 检查才会被调用。它接受 debug 值作为参数,并且会返回一个格式化的显示值。
例如,一个返回 Date 值的自定义 Hook 可以通过格式化函数来避免不必要的 toDateString 函数调用
1 | useDebugValue(date,date=>date.toDateString()) |